Code Conventions
Module Rules
lib.zigis the public surface. Each directory that forms a logical module exposes exactly onelib.zig. Internal files are not imported directly by outside code.types.zigholds shared types. Types shared within a module live intypes.zig. Cross-module types live in the nearest common ancestor'stypes.zig.One primary struct per file. A file named
Foo.zigexports exactly one primarypub const Foo = struct { ... }. Helper types used only byFoomay be private.No
std.fsoutsideutility/andcli/. File system access is banned in core logic and GUI code. Onlyutility/Vfs.zigand CLI entry points may touch the filesystem. This keeps the WASM backend working.No mutations in GUI code. GUI code pushes
Commandvalues to the command queue. It does not mutateAppStateorDocumentdirectly.No allocations in render functions. Frame functions must be allocation-free to avoid per-frame GC pressure. Pre-allocate in init; use scratch buffers for temporaries.
Naming
| Thing | Convention | Example |
|---|---|---|
| Types / structs | PascalCase | Schematic, PlaceInstanceCmd |
| Functions | camelCase | emitSpice, renderCanvas |
| Variables / fields | camelCase | activeTab, wireCount |
| Comptime constants | SCREAMING_SNAKE | ABI_VERSION |
| Unused parameters | _ prefix |
fn foo(_unused: u32) |
| File names (types) | PascalCase | FileIO.zig, AppState.zig |
| File names (modules) | lowercase | state.zig, lib.zig |
Data-Oriented Design
Prefer Structure-of-Arrays over Array-of-Structs for hot data:
// Avoid: strides through full struct to reach x coordinates
var instances: ArrayList(Instance) = .{};
for (instances.items) |inst| { _ = inst.x; }
// Prefer: contiguous x array, cache-friendly
var instances: MultiArrayList(Instance) = .{};
const xs = instances.items(.x);
for (xs) |x| { _ = x; }
Separate hot (per-frame) and cold (property editor) data:
// Hot: rendered every frame
const InstanceHot = struct { x: i32, y: i32, kind: DeviceKind, rot: u2, flip: bool };
// Cold: accessed only in property dialog
const InstanceCold = struct { name: []const u8, description: []const u8 };
Error Handling
// Good: explicit error union, caller knows what failed
pub fn readSchematic(path: []const u8) !Schematic { ... }
// Bad: optional doesn't explain failure
pub fn readSchematic(path: []const u8) ?Schematic { ... }
// Good: propagate with try
const data = try Vfs.readAlloc(alloc, path);
// Bad: swallow errors silently
const data = Vfs.readAlloc(alloc, path) catch return .{};
- Never
catch unreachablein production paths - Catch only at true boundary points (user input, file I/O, plugin calls)
- Allocator errors are always propagated, never swallowed
Allocator Discipline
// Pass allocator as parameter — never store globally
pub fn parseChN(allocator: Allocator, input: []const u8) !Schematic { ... }
// Free immediately after use
const data = try Vfs.readAlloc(alloc, path);
defer alloc.free(data);
// Arena for scratch work within a function
var arena = ArenaAllocator.init(alloc);
defer arena.deinit();
const scratch = arena.allocator();
Adding a Command
1. Add to UndoableAction in commands/lib.zig:
pub const MyCmd = struct { target_idx: u32, new_val: []const u8 };
pub const UndoableAction = union(enum) {
// ...existing...
my_command: MyCmd,
};
2. Write handler in commands/handlers/MyCommand.zig:
pub fn handle(cmd: MyCmd, doc: *Document) !void { ... }
pub fn undo(cmd: MyCmd, doc: *Document) !void { ... }
3. Register in commands/Dispatch.zig:
.my_command => |cmd| try MyCommand.handle(cmd, doc),
4. Trigger from GUI:
actions.enqueue(app, .{ .undoable = .{ .my_command = .{
.target_idx = idx,
.new_val = "value",
} } }, "My command description");
Adding a Plugin Message
1. Add tag to PluginIF.Tag enum — host→plugin tags 0x01–0x7F, plugin→host 0x80–0xFF.
2. Add variant to InMsg or OutMsg union.
3. Update Reader.next() to decode the new tag.
4. Add Writer method to encode the new message.
5. Handle in plugins/Runtime.zig.
6. Bump ABI_VERSION only if change is breaking (removed/reordered tags). Adding new tags is backward compatible — unknown tags are skipped by the reader.
GUI Frame Rules
// Good: read state, emit draw calls + commands
fn renderToolbar(state: *const AppState, q: *CommandQueue) void {
if (dvui.button("Place Wire")) {
q.push(.{ .undoable = .{ .begin_wire = .{} } });
}
}
// Bad: mutate state directly in render function
fn renderToolbar(state: *AppState) void {
if (dvui.button("Place Wire")) {
state.active_doc.tool = .wire; // never do this
}
}
Render functions must be idempotent: calling twice with the same state produces identical output.